Fix cross-device sync, notifications, image proxy and the share control (v0.11.5 / android-v0.11.3) - #23
Merged
Merged
Conversation
Sync between the web client and Android was failing in ways that all looked like "the app just doesn't update", and the audit found four independent causes plus two that stopped notifications from arriving at all. Sync - The web client aborted its whole push on a 400. The watermark never moved, so the same rejected record went back up every 45 seconds and every other local change queued behind it forever, while pull kept working and made sync look healthy. It now halves a rejected batch to isolate the offending operation, reports it, and lets the rest through, as Android already did. - The server replaced a playback_state payload with its own parsed struct, dropping the title, artwork, podcast id, enclosure, duration and categories the clients denormalize into it. The sync log is the only place that data lives, so the receiving device rebuilt progress rows with no title and overwrote good local ones. Normalization now merges into the original object and unknown keys round-trip. - Listening sessions could exceed the server's own ceilings (a player paused across a holiday spans more than seven days), making them permanently unpushable. Both clients clamp before sending, holding the end timestamp that last-writer comparisons key on. - An empty folder in a subscription payload cleared the folder on the peer; Android already guarded this, the web did not. Android additionally lost updated_at on pulled subscriptions. Notifications - The Web Push Topic header was "podcast-" plus a UUID: 44 characters against RFC 8030's 32-character cap, so push services rejected every new-episode notification. It is now a hash of the podcast id, keeping the collapsing behaviour within the allowed length. - Healthy feeds were rescheduled 24 hours out, so a daily show could be announced most of a day late. The interval is configurable via FEED_REFRESH_INTERVAL_MS and defaults to an hour; conditional requests keep it cheap. The push send loop no longer holds a SQLite read cursor across third-party network calls while deleting from the same table. - The Android new-episode notification was built from English literals, so German listeners got English text in the one place they see without opening the app. It uses string resources now, with tests pinning both locales and the plural's format arguments. Android - Pull-to-refresh reached only the Inbox. It now covers Library, Profile and the community statistics (which sync) and Discover and a podcast (which re-read the feed). Downloads is deliberately excluded: it is device-local, and a gesture that does nothing is worse than none. - Returning from the background waited out the remainder of the 45-second tick, and a cached process is frozen, so an episode finished elsewhere could sit there unplayed for most of a minute. The coordinator now syncs on foreground; the periodic tick stays ungated so progress from screen-off listening keeps reaching the account. Sharing - The episode share control was labelled "continue on another device" and quietly wrote to the clipboard on any browser without a native share sheet. It is a share control now: it opens, shows the exact link, offers the system share sheet where one exists and copy or email everywhere else, and lets the listener choose whether the timestamp is included. Android's button carried the same wrong name for what was already a real share sheet. Hardening - A registration losing the unique-index race answers 409 rather than 500. - Recovery-code verification spends the same work as a real check, so it cannot be used to enumerate usernames the way login already prevented. - The SSRF blocklist covers the NAT64 well-known prefixes, benchmarking and IETF protocol assignment ranges. Verified on an Android emulator against a local instance: subscriptions, folders and playback state cross over, pull-to-refresh triggers a sync, the foreground trigger fires within half a second, and the share sheet opens with the timestamped link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proxy sent a browser's Accept header, AVIF first. Every CDN that negotiates on it — imgix, Cloudinary, Cloudflare Images, anything serving `auto=format` — honoured that and returned AVIF. This build registers JPEG, PNG, GIF and WebP decoders and no AVIF one, so the decode failed and the handler answered with its own placeholder at 200, complete with a Cache-Control header. Artwork that was never broken rendered as a grey rectangle, and the 200 made it invisible in the access log. Accept now lists only what the registered decoders can read. Confirmed against a real imgix cover: `image/avif` before, `image/jpeg` and no X-KoalaCast-Image-Fallback after, and the cover renders on device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full audit of the API, the web client and the Android app, with fixes for everything found. Verified end to end on an Android emulator against a local instance.
Why sync looked broken
Four independent causes, all of which read as "the app just doesn't update":
400aborted the whole push, so the watermark never advanced and the same record went back up every 45 seconds — with every other local change queued behind it, forever. Pull kept working, which is why sync looked healthy. It now halves a rejected batch to isolate the offender, reports it, and lets the rest through, as Android already did.updated_aton pulled subscriptions.Why notifications never arrived
Topicheader was 44 characters (podcast-+ UUID) against RFC 8030's 32-character cap, so push services rejected every new-episode notification. It is a hash of the podcast id now.FEED_REFRESH_INTERVAL_MS, default one hour; conditional requests keep it cheap. The send loop no longer holds a SQLite read cursor across third-party network calls while deleting from the same table.Why cover art was missing
The image proxy sent a browser's
Acceptheader with AVIF first. Every CDN that negotiates on it (imgix, Cloudinary,auto=format) returned AVIF; this build has no AVIF decoder, so the decode failed and the handler answered with its own placeholder at 200. Artwork that was never broken rendered as a grey rectangle, and the 200 hid it from the access log.Acceptnow lists only what the registered decoders can read.Android
Sharing
The episode share control was labelled "continue on another device" and quietly wrote to the clipboard on any browser without a native share sheet. It is a share control now: it opens, shows the exact link, offers the system share sheet where one exists and copy or email everywhere else, and lets the listener choose whether the timestamp is included. Android's button carried the same wrong name for what was already a real share sheet.
Hardening
Verification
Go, web (143 tests,
svelte-checkclean) and Android suites all pass; translation, docs and release-policy checks pass. On the emulator against a local instance: subscriptions, folders and playback state cross over, pull-to-refresh triggers a sync, the foreground trigger fires ~460 ms after opening, the share sheet opens with the timestamped link, and cover art renders after the proxy fix.Known, not fixed
stat_sys_download_doneas its small icon. The repo's rule is not to hand-draw icon paths, and there is no suitable bell/RSS drawable in the project yet.podcasts.update_frequency_msis dead schema — the worker has never read it. Left alone rather than churn a migration.Versions:
apps/web0.11.5, AndroidversionCode43 /versionName0.11.3. Tags follow after merge.🤖 Generated with Claude Code